Skip to content

feat(init): replace OpenTUI with Ink for the wizard UI#885

Open
MathurAditya724 wants to merge 9 commits intofeat/init-wizard-ui-abstractionfrom
feat/init-wizard-ink
Open

feat(init): replace OpenTUI with Ink for the wizard UI#885
MathurAditya724 wants to merge 9 commits intofeat/init-wizard-ui-abstractionfrom
feat/init-wizard-ink

Conversation

@MathurAditya724
Copy link
Copy Markdown
Member

Stacked on top of #862 (OpenTUI / WizardUI scaffold). Once #862 merges, GitHub auto-retargets this PR to main.

Summary

Replaces the OpenTUI implementation (Zig-compiled native binary, ~10.7 MB of binary cost, Bun-only) with Ink — pure JS + React, no native bindings. Same WizardUI surface, same WizardStore, same sidebar layout, same step checklist — different render primitives.

Why

  • No native binary cost. OpenTUI ships per-platform .so/.dylib/.dll files via bun:ffi, inflating the binary by ~10.7 MB. Ink is pure JS, so the binary drops from ~118 MB → ~109 MB (-9.4 MB).
  • No alternate-screen flicker. OpenTUI took over the alternate-screen buffer; on dispose every trace of the run was wiped. We had to replay a stripped-down transcript to stderr so users had any scrollback. Ink renders inline.
  • Mature ecosystem. ink + ink-spinner cover most of what we hand-rolled in OpenTUI. Used by Wrangler, Gatsby, GitHub Copilot CLI, and others.

What's in this PR

Four commits, in order:

  1. d1ca5f7a feat(init): replace OpenTUI with Ink — initial port. Includes the with { type: "file" } workaround for Bun.compile bundling React's CJS dev wrappers (otherwise hits a __promiseAll SyntaxError at startup).
  2. 59900dbd fix(init): make Ink select prompt actually respond to arrow keys — replaced ink-select-input with a hand-rolled useInput implementation. The third-party component races with our store-driven re-renders.
  3. b4a591e2 fix(init): make Ink useInput actually deliver keystrokes in Bun — pass a fresh /dev/tty ReadStream to Ink's stdin option to work around oven-sh/bun#6862 + vadimdemedes/ink#636 (Bun's process.stdin doesn't deliver readable events).
  4. 4a3e8354 fix(init): clear screen on dispose + tighten sidebar layoutinstance.clear() before unmount so the wizard chrome doesn't linger above the post-dispose summary; removed wasted rows between sidebar panels.

Things that stayed the same

  • WizardUI interface (banner / intro / log / spinner / select / multiselect / confirm / summary / cancel / outro / setStep / recordFilesReading / markFilesAnalyzed)
  • The external WizardStore + useSyncExternalStore subscription pattern (renamed opentui-store.tswizard-store.ts)
  • file-tree.ts, sentry-tips.ts, types.ts (unchanged in shape)
  • Sidebar layout: tip card on top, step checklist in the middle, files-read tree on the bottom
  • Step progress checklist with implicit-skip back-fill
  • Post-dispose chalk summary echoed after Ink unmounts

Things that changed

  • Sidebar tree window vs. scrollbox. Ink doesn't ship a scrollbox primitive. The files-read panel now shows the last N rows that fit, with a ↑ N earlier (scrolled) hint when truncated. The tail-f UX (newly-read files always visible) comes for free since the panel re-renders to the bottom.
  • Multi-select. Built directly on Ink's useInput (no third-party multiselect component).
  • Cancellation. OpenTUI's keyHandler was global; Ink's useInput is per-component. Cancellation now hooks into:
    • Each prompt's own useInput (handles key.escape and key.ctrl && input === "c" in raw mode where Node doesn't emit SIGINT).
    • A top-level App-component useInput that intercepts Ctrl+C during spinners (no prompt mounted).
    • A process.on("SIGINT", …) fallback inside InkUI for the brief window where raw mode flickers off.

Bun-binary-only (same as OpenTUI was)

Ink's reconciler and the yoga-layout dependency use top-level await, which esbuild can't emit in our CJS npm bundle. So Ink is bundled into the Bun binary via the with { type: "file" } trick (same as OpenTUI used) but excluded from dist/index.cjs entirely. Node users continue to get LoggingUI — unchanged from before. This preserves AGENTS.md's "no runtime dependencies" rule. bun run check:deps passes.

Bun.compile workarounds (unavoidable)

  • with { type: "file" } keeps ink-app.tsx out of esbuild's and Bun.compile's static bundle graph. Without this, Bun.compile mangles Ink's and React's CJS dev wrappers (it injects __promiseAll runtime helpers in positions the IIFEs can't parse, producing SyntaxError: Unexpected identifier '__promiseAll' at startup inside parse-keypress.js or react-jsx-runtime.development.js).
  • ?bridge=1 query string on the dynamic import bypasses Bun's module-cache collision between the file-resource import and await import(path) of the same absolute path.
  • define: { 'process.env.NODE_ENV': '"production"' } on Bun.build forces React to use its production builds.
  • react-devtools-core installed as a devDep so Bun.compile can resolve Ink's static reference (gated behind process.env.DEV === "true" at runtime → dead code in production).

Files changed

Added

  • src/lib/init/ui/ink-app.tsx — Ink React tree
  • src/lib/init/ui/ink-ui.tsInkUI bridge class

Renamed

  • src/lib/init/ui/opentui-store.tswizard-store.ts (no logic changes)
  • test/lib/init/ui/opentui-store.test.tswizard-store.test.ts

Deleted

  • src/lib/init/ui/opentui-app.tsx
  • src/lib/init/ui/opentui-ui.ts

Dep changes

  • Removed: @opentui/core, @opentui/react
  • Added: ink, ink-spinner, react-devtools-core (all devDependencies)

Verification

  • bun run typecheck (clean)
  • bun x ultracite check (1 pre-existing warning, no new ones)
  • bun test --isolate test/lib/init/ (227 pass)
  • bun run check:deps (no runtime dependencies)
  • SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB, -9.4 MB vs OpenTUI's 118.23 MB)
  • SENTRY_CLIENT_ID=test bun run bundle (npm 3.29 MB, unchanged)
  • ./dist-bin/sentry-linux-x64 init --help (renders cleanly)
  • node ./dist/bin.cjs init --help (Node path renders cleanly)

Swaps the OpenTUI implementation introduced in PR 4 for an Ink-based
one. Same WizardUI surface, same store/store-mutators/file-tree, same
sidebar layout (tip card + progress checklist + files-read tree) —
just different render primitives.

Why Ink?

  - No native bindings. OpenTUI's renderer is Zig-compiled and
    shipped as ~4.5 MB of platform-specific .so/.dylib/.dll files
    loaded via Bun's bun:ffi. The compiled CLI binary inlined that
    plus a ~6 MB JS bindings layer, costing ~10.7 MB. Ink is pure
    JS + React, dropping the binary by ~9.4 MB (118.23 → 108.79 MB).
  - No alternate-screen flicker. OpenTUI took over the whole
    terminal via the alternate-screen buffer; on dispose it wiped
    every trace of the run. We had to replay a stripped-down
    transcript to stderr so users had any scrollback. Ink renders
    inline, so log lines accumulate naturally and the user keeps
    everything in their terminal history.
  - Mature ecosystem. ink-spinner, ink-select-input, etc. cover
    most of what we hand-rolled in OpenTUI. Used by Wrangler,
    Gatsby, GitHub Copilot CLI, and others.

Things that stayed the same:

  - WizardUI interface (banner / intro / log / spinner / select /
    multiselect / confirm / summary / cancel / outro / setStep /
    recordFilesReading / markFilesAnalyzed)
  - The external WizardStore + useSyncExternalStore subscription
    pattern (renamed from opentui-store.ts to wizard-store.ts)
  - file-tree.ts, sentry-tips.ts, types.ts (unchanged)
  - Sidebar layout: tip card (fixed 12 rows) on top, step
    checklist in the middle, files-read tree on the bottom
  - Step progress checklist with implicit-skip back-fill
  - Post-dispose chalk summary echoed to stderr after Ink unmounts

Things that changed:

  - Sidebar tree window vs. scrollbox. Ink doesn't ship a
    scrollbox primitive. The files-read panel now shows the *last*
    N rows that fit, with a "… N earlier" hint when truncated. The
    tail-f UX (newly-read files always visible) comes for free
    since the panel re-renders to the bottom.
  - Multi-select. Built directly on Ink's useInput. ink-select-
    input doesn't expose a way to draw bracketed [✔] markers in
    addition to the cursor.
  - Cancellation. OpenTUI's keyHandler is global; Ink's useInput
    is per-component. Cancellation now hooks into process-level
    SIGINT (Ink's exitOnCtrlC: false lets us route Ctrl+C through
    our cooperative-cancel path instead of yanking the process).

Bun-binary-only (same as OpenTUI was):

  - Ink's reconciler and yoga-layout use top-level await, which
    esbuild can't emit in our CJS npm bundle. So Ink is bundled
    into the Bun binary via the with-file import trick (same as
    OpenTUI used) but excluded from dist/index.cjs entirely. Node
    users continue to get LoggingUI — unchanged from before.
  - This preserves AGENTS.md's "no runtime dependencies" rule.
    bun run check:deps passes.

Bun.compile workarounds (carried over from the OpenTUI fix in this
PR series):

  - The with-file import keeps ink-app.tsx out of esbuild and
    Bun.compile's static bundle graph. Without this, Bun.compile
    mangles Ink's and React's CJS dev wrappers (it injects
    __promiseAll runtime helpers in positions the IIFEs can't
    parse, producing "SyntaxError: Unexpected identifier
    '__promiseAll'" at startup inside e.g. parse-keypress.js or
    react-jsx-runtime.development.js).
  - ?bridge=1 query string on the dynamic import bypasses Bun's
    module-cache collision between the file-resource import and
    the dynamic import of the same absolute path. Same workaround
    we landed earlier for OpenTUI.
  - define process.env.NODE_ENV=production on Bun.build forces
    React to use its production builds; the dev builds otherwise
    trigger the __promiseAll bug even via the embedded-file path.
  - react-devtools-core installed as a devDep so Bun.compile can
    resolve the static reference inside Ink's reconciler. The
    actual import is gated behind process.env.DEV === "true" so
    it's dead code in production.

Files added:
  - src/lib/init/ui/ink-app.tsx — Ink React tree (renamed from
    opentui-app.tsx, fully rewritten for Ink primitives)
  - src/lib/init/ui/ink-ui.ts — InkUI bridge class (renamed from
    opentui-ui.ts, ported to Ink's render() API)

Files renamed:
  - src/lib/init/ui/opentui-store.ts → wizard-store.ts (no logic
    changes — just docstring updates removing OpenTUI references)
  - test/lib/init/ui/opentui-store.test.ts → wizard-store.test.ts

Files deleted:
  - src/lib/init/ui/opentui-app.tsx
  - src/lib/init/ui/opentui-ui.ts

Dep changes:
  - REMOVED: @opentui/core, @opentui/react
  - ADDED:   ink, ink-spinner, ink-select-input, ink-text-input,
             react-devtools-core (all devDependencies)

Verification:
  - bun run typecheck (clean)
  - bun x ultracite check (1 pre-existing warning, no new ones)
  - bun test --isolate test/lib/init/ (227 pass)
  - bun run check:deps (no runtime dependencies)
  - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
    -9.44 MB vs. OpenTUI's 118.23 MB)
  - SENTRY_CLIENT_ID=test bun run bundle (npm 3.29 MB, unchanged)
  - ./dist-bin/sentry-linux-x64 init --help (renders cleanly)
  - node ./dist/bin.cjs init --help (Node path renders cleanly)
  - Smoke test creating an InkUI and exercising every WizardUI
    method produced no React reconciler errors and a clean
    post-dispose summary.
Replaces `ink-select-input` with a hand-rolled select prompt built
on Ink's `useInput` hook directly. Same pattern as our existing
`MultiSelectPrompt` — same cursor glyph, same accent color, same
hint placement, same keyboard handling.

Why? `ink-select-input`'s items array is recreated on every parent
render, which races with its internal `useEffect` that resets
`selectedIndex` on items-change. Under our store-driven re-render
cadence (tip rotation every 8s, log lines, file-read updates) the
cursor never settled and arrow keys felt unresponsive — the user
reported the experimental-confirm prompt couldn't be navigated or
selected.

Doing the same `useInput`-based render that `MultiSelectPrompt`
already uses gives us:

  - Stable state across re-renders (cursor lives in our own
    `useState`, no externally-driven reset).
  - Consistent visual styling between single- and multi-select.
  - Escape-to-cancel handling. The bridge translates `resolve(null)`
    to the shared `CANCELLED` sentinel, so the wizard runner's
    cancellation path triggers cleanly.

Also drops `ink-select-input` and `ink-text-input` from devDeps
(both unused now) and updates the build/bundle externals lists.

Verification:
  - bun run typecheck (clean)
  - bun x ultracite check (1 pre-existing warning, no new ones)
  - bun test --isolate test/lib/init/ (227 pass)
  - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
    no size regression)
  - InkUI smoke test renders cleanly through the dispose path.
Root cause: a known Bun + Ink interaction bug
(oven-sh/bun#6862, vadimdemedes/ink#636, both still open). Ink's
`useInput` hook listens for `readable` events on its stdin
(default `process.stdin`) and pulls bytes via `stdin.read()`. Bun's
compiled binaries have a long-standing issue where the inherited
fd 0 accepts `setRawMode(true)` but never delivers `readable`
events for terminal input. So:

  - the wizard rendered fine (Ink's stdout writes are unaffected),
  - but arrow keys, Enter, and Ctrl+C all did nothing —
    `useInput` listeners never fired,
  - and "can't exit the program" because raw mode suppresses
    SIGINT delivery for Ctrl+C, and our SIGINT fallback handler
    never ran either.

Fix: open a fresh `/dev/tty` `tty.ReadStream` ourselves and pass
it to Ink as the `stdin` option. Fresh fds opened from inside the
process don't trigger the inheritance bug, so their `readable`
events fire correctly. Ink's `setRawMode(true)` on the fresh
stream toggles termios on the underlying TTY device — the same
device fd 0 points at — so the user's terminal still goes raw,
just via a different fd. We close the stream on dispose to
release the libuv handle.

Bonus fixes wrapped in:

  1. **Ctrl+C handling in raw mode.** Each prompt's `useInput`
     now treats `key.ctrl && input === "c"` as a cancel (same
     path as Esc). A top-level `useInput` in the App component
     handles Ctrl+C during spinners (no prompt mounted) by
     calling `process.exit(130)` so users can always abort.
  2. **Removed dead `forwardFreshTtyToStdin()` call.** The
     macOS-only workaround in `wizard-runner.ts` was clack-era
     dead code: `LoggingUI` doesn't read stdin (its prompts
     throw), and `InkUI` now opens its own /dev/tty. The
     function is preserved in `stdin-reopen.ts` for future
     callers but no longer wired in. This also removes a class
     of conflicts where the workaround's no-op `_read` and
     data-event forwarding actively broke Ink's stdin reading
     on macOS.
  3. **Stdin teardown.** `InkUI.[Symbol.asyncDispose]` now
     calls `setRawMode(false)` and `destroy()` on the fresh
     stream so the user's shell isn't left in raw mode if the
     wizard crashes mid-prompt.

Verification:
  - bun run typecheck (clean)
  - bun x ultracite check (1 pre-existing warning, no new ones)
  - bun test --isolate test/lib/init/ (227 pass)
  - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
    no size regression)
  - Binary smoke (init --help) renders cleanly.
  - Embedded ink-app.tsx + new openFreshTtyForInk helper visible
    in compiled binary's strings dump.

Caveats this fix carries forward:
  - Still requires `react-devtools-core` as a devDep so
    Bun.compile can resolve Ink's static reference (gated behind
    `process.env.DEV === "true"` at runtime, dead code in prod).
  - macOS-only force-exit timer in `init.ts` still fires after
    runWizard returns to drain the libuv handle for our fresh
    /dev/tty stream (same root cause as before, just different
    fd source). Comment updated to reflect the new owner.
Two visual fixes called out by the user:

1. **Clear the wizard chrome before printing the post-dispose
   summary.** Previously the bordered wizard box stayed on screen
   above the chalk summary, which was redundant and visually
   noisy. `instance.clear()` now runs immediately before `unmount()`
   so Ink rewinds the cursor and overwrites the rendered region;
   the post-dispose `Setup complete` line + summary becomes the
   only thing left on screen. The summary now writes to stdout
   (was stderr) so it lands in the same stream as the cleared
   Ink output — avoids potential interleave issues when the user
   pipes stdout/stderr separately.

2. **Tighten sidebar spacing.** The three sidebar panels
   (TipPanel, ProgressPanel, FilesPanel) had a `gap={1}` between
   them, plus 1-row inner margins between each panel's title and
   body. That was ~7 wasted rows on a typical run. Removed:
     - The outer `gap={1}` between panels (now flush borders).
     - `marginBottom={1}` after each panel title.
     - `marginTop={1}` between TipPanel body and counter.
   Tip-card body and counter are now stacked directly via the
   normal flex flow; the rounded border + `paddingX={1}` already
   provides enough visual separation. The `Did you know?` heading
   moved into the bottom counter row (`Tip 3 of 12 · Did you
   know?`) so the title row isn't wasted on a static label that
   never changed.

3. **Better files-panel truncation indicator.** The "scroller"
   the user asked for can't be a real interactive scroller —
   Ink doesn't ship a scrollbox primitive, the file tree updates
   frequently (new reads push the bottom), and adding `useInput`
   to the panel would compete with the active prompt for key
   events. Instead the tail-window UX is preserved with a
   clearer indicator: `↑ N earlier (scrolled)` at the top when
   rows are off-screen, and the panel header already shows
   `Files analyzed (n/total)` so the user sees the full count.
   Reserving 1 row for the header inside the maxRows budget
   means the actual file-row count is honoured (previously the
   header could squeeze the last visible file row).

Verification:
  - bun run typecheck (clean)
  - bun x ultracite check (1 pre-existing warning, no new ones)
  - bun test --isolate test/lib/init/ (227 pass)
  - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
    no size regression)
  - Smoke test confirmed: post-dispose summary stands alone,
    no wizard box above it.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-885/

Built to branch gh-pages at 2026-04-30 17:01 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

Codecov Results 📊

6338 passed | Total: 6338 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests 📉 -5
Passed Tests 📉 -5
Failed Tests
Skipped Tests

All tests are passing successfully.

❌ Patch coverage is 47.46%. Project has 13460 uncovered lines.
❌ Project coverage is 75.73%. Comparing base (base) to head (head).

Files with missing lines (5)
File Patch % Lines
src/lib/init/ui/ink-app.tsx 50.15% ⚠️ 337 Missing
src/lib/init/wizard-runner.ts 0.00% ⚠️ 25 Missing
src/commands/init.ts 7.14% ⚠️ 13 Missing
src/lib/init/ui/factory.ts 0.00% ⚠️ 7 Missing
src/lib/init/ui/logging-ui.ts 0.00% ⚠️ 1 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
- Coverage    76.03%    75.73%     -0.3%
==========================================
  Files          294       302        +8
  Lines        54404     55463     +1059
  Branches         0         0         —
==========================================
+ Hits         41362     42003      +641
- Misses       13042     13460      +418
- Partials         0         0         —

Generated by Codecov Action

Comment on lines +165 to +169
useInput((input, key) => {
if (key.ctrl && input === "c" && !snapshot.prompt) {
process.exit(130);
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Pressing Ctrl+C during a spinner calls process.exit(130) directly, bypassing cleanup logic and leaving the terminal in a broken state (raw mode).
Severity: HIGH

Suggested Fix

Instead of calling process.exit(130) directly in the useInput handler, the component should signal the InkUI instance to initiate a graceful shutdown. This could be done by calling a dedicated cancel method, which would then throw a WizardCancelledError. This allows the await using block in wizard-runner.ts to correctly trigger the [Symbol.asyncDispose] method, ensuring all resources are cleaned up and the terminal state is restored properly before the process exits.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/init/ui/ink-app.tsx#L165-L169

Potential issue: When a user presses Ctrl+C while a spinner is active (and no prompt is
shown), a `useInput` handler in `ink-app.tsx` directly calls `process.exit(130)`. This
immediate process termination bypasses the `await using` disposal logic for the `InkUI`
instance. As a result, critical cleanup tasks in `[Symbol.asyncDispose]` are skipped.
This leaves the user's terminal in raw mode (disabling character echo) and leaks a
`/dev/tty` stream handle, which can prevent the process from exiting cleanly.

Did we get this right? 👍 / 👎 to inform future reviews.

Comment thread src/lib/init/ui/ink-ui.ts Outdated
CI's stricter biome version flagged the multi-line ternary in
`FilesPanel`'s `visible` assignment. Auto-fixed by
`bun x biome format --write`.
Comment thread src/lib/init/ui/ink-ui.ts Outdated
Comment thread src/lib/init/ui/ink-ui.ts Outdated
Addresses two HIGH-severity bug-prediction findings on PR #885 and
restores the visual polish that the OpenTUI version had:

1. Ctrl+C during a spinner no longer calls process.exit(130)
   directly. The App's top-level useInput now routes through
   store.requestCancel(), which the InkUI bridge wires to a single
   requestCancel() entry point. That entry point either delegates
   to an active prompt's cancel callback (preserving the existing
   WizardCancelledError flow) or runs an idempotent tearDown()
   followed by process.exit(130) on the no-prompt path.

2. The SIGINT handler now funnels through the same requestCancel()
   so terminal restoration, /dev/tty release, post-dispose summary
   emission, and exit code are uniform across all three cancel
   entry points (App useInput, SIGINT, prompt). Switched
   process.on -> process.once so a stuck teardown can't hold the
   user hostage if Ctrl+C is pressed twice.

3. Two idempotency guards (torndown, cancelRequested) make
   tearDown() and the no-prompt branch of requestCancel() safe to
   call from multiple paths racing each other.

Visual polish:

- Divider now tracks main-column width (passed via prop) instead of
  hard-coding ".repeat(50)", so it doesn't truncate when the
  sidebar is visible nor look stubby on wide terminals. Capped at
  56 to match banner row width.

- Sidebar panel headers (TipPanel, ProgressPanel, FilesPanel) use
  bold-muted eyebrow + right-aligned counter pattern instead of a
  bold-accent title row. Reads as proper section chrome rather
  than competing with the actual content highlight (tip title in
  ACCENT) for the eye.

- TipPanel counter moved to right-aligned bottom row so "Tip n of
  N" doesn't share a line with "Did you know?" eyebrow.

Tests added for WizardStore.setRequestCancel covering initial
state, registration, idempotency by reference, clearing on
teardown, and round-trip invocation.
Sweep the surrounding init/ files (everything except ink-ui.ts and
ink-app.tsx, where OpenTUI references intentionally document
history) so doc comments accurately describe the current Ink-based
implementation. No behavior changes.

Touched files:
- clack-utils.ts: sidebar comment refs
- formatters.ts: header explaining why summary is structured data
- git.ts, interactive.ts: paths through WizardUI
- types.ts (init): forceLegacyUi rationale
- ui/file-tree.ts: tree builder's two consumers
- ui/logging-ui.ts: tree-row format docstring
- ui/sentry-tips.ts: where tips render
- ui/types.ts: WizardSummary, banner, summary, recordFilesReading,
  setStep doc rewrites
- wizard-runner.ts: header + four inline references
Adds `test/lib/init/ui/ink-app.snapshot.test.tsx` — four smoke tests
that mount the React tree directly via Ink's `render()` API with a
captured `Writable` stream, then assert against the rendered frames:

1. **Banner + sidebar at 120 cols.** Verifies the box-drawing
   banner renders, the sidebar's three panels (`Did you know?`,
   `Progress`, file tree) appear, and live log/spinner content
   reaches the frame.

2. **Sidebar hides at 80 cols.** Confirms `SIDEBAR_BREAKPOINT`
   gating works — banner + log render but the panel headers are
   absent.

3. **Divider tracks main-column width.** Asserts the new
   `Divider` component grows up to 56 cols on an 80-col terminal
   (vs. the old hard-coded 50). Picks the second-longest unique
   `─` run length to skip the outer wizard chrome border.

4. **Ctrl+C path uses requestCancel.** Smoke-checks that
   `WizardStore.setRequestCancel` round-trips correctly so the
   App's top-level `useInput` handler can route Ctrl+C through
   the bridge's idempotent teardown rather than calling
   `process.exit(130)` directly.

Why mount Ink directly instead of spawning the binary?
Bun-compiled standalone binaries report `process.stdin.isTTY:
false` even when launched through `script(1)` or a hand-allocated
`/dev/ptmx` pair (verified via `dlopen("libc.so.6")` +
`posix_openpt` from a driver script). The `factory.ts`
TTY check therefore routes to `LoggingUI` and prompts throw
`LoggingUIPromptError` before any visual work happens. Mounting
the React tree directly via Ink's `render()` API sidesteps that
detection and exercises the actual visual contract.

The `CaptureStream` writable concatenates every chunk Ink emits
(it splits a render across cursor moves + sync flag + content +
sync unflag — the last chunk alone is usually a control
sequence). The `makeStdin()` shim implements just enough of the
`ReadStream` surface for Ink's raw-mode toggling.

All 6337 tests pass (+4 new); biome lint clean (1 pre-existing
warning unchanged).
Restores the scrollable Files Analyzed sidebar that the OpenTUI
implementation had via `<scrollbox stickyStart="bottom">`. Ink
doesn't ship a scrollbox primitive, so this is a hand-rolled
component with two improvements over the auto-only OpenTUI version:

1. **Visual scrollbar.** A 1-column track of `│` glyphs on the
   right edge of the panel, with a `█` thumb whose position +
   size reflects the visible window's place in the full
   read-tree. Shown only when content exceeds the viewport;
   hidden when everything fits.

2. **Keyboard scroll-back.** While no prompt is mounted (gated
   on `!hasActivePrompt` so it never fights a select/multiselect's
   own `useInput`):
   - ↑ / ↓        — scroll one row
   - PgUp / PgDn  — scroll one viewport
   - Home         — jump to oldest entry
   - End / Esc    — re-pin to latest (auto-follow)

Auto-follow ("pinned to bottom") is the default — newly-read
files always come into view, like `tail -f`. The user can scroll
back; while unpinned, new file arrivals don't shift the visible
window (the panel bumps `offset` by the new-row count to keep
the view stable). Pressing PgDn / ↓ until offset reaches 0
re-pins automatically, so the user doesn't need to remember to
press End.

Architecture:
- Scroll state (`pinnedToBottom`, `offset`) is React `useState`
  inside FilesPanel — UI concern, not wizard-store domain.
- A `useEffect` watching `totalRows` handles three cases:
  arrival-while-unpinned (bump offset), shrink (clamp offset to
  new maxOffset), and pinned-to-bottom (no-op).
- Header shows a `↑ ` prefix on the counter when scrolled up,
  signaling the panel won't auto-follow.
- App threads `hasActivePrompt = snapshot.prompt !== null`
  through Sidebar to FilesPanel — single boolean, no
  refactoring of existing layout.

Tests:
- New snapshot test "FilesPanel renders scrollbar when content
  exceeds viewport" compares `█` counts between a few-files
  baseline and a many-files render. Banner ASCII art uses U+2588
  too, so exact-pattern matching can't distinguish thumb from
  banner — count-delta works around that.
- Header pinned-state regex confirms the `↑ ` prefix is absent
  when (default) the panel auto-follows.

Verification: 6338/6338 tests pass (+1 new), lint clean (1
pre-existing warning unchanged), binary at 108.80 MB
(unchanged).
Comment thread src/lib/init/ui/ink-ui.ts
Comment on lines +711 to +717
private installCancelHandler(): void {
const handler = () => {
this.requestCancel();
};
this.cancelHandler = handler;
process.once("SIGINT", handler);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The SIGINT handler uses process.once, so a second Ctrl+C can terminate the process without cleanup, leaving the terminal in raw mode.
Severity: MEDIUM

Suggested Fix

Replace process.once('SIGINT', handler) with process.on('SIGINT', handler) to ensure the handler persists across multiple signals. To prevent the handler from remaining active after the wizard is complete, explicitly remove it by calling process.off('SIGINT', handler) as part of the tearDown logic.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/init/ui/ink-ui.ts#L711-L717

Potential issue: The `SIGINT` handler is registered using `process.once`, which means it
is removed after its first invocation. If a user sends a `SIGINT` signal (e.g., via
Ctrl+C) while a prompt is active, the handler correctly delegates to `promptCancel()`
and returns. However, this action consumes the handler. If the user sends a second
`SIGINT` signal later in the process (e.g., during a spinner), no custom handler exists.
This falls through to Node's default behavior, which terminates the process immediately,
bypassing cleanup and leaving the terminal in raw mode.

Also affects:

  • src/lib/init/ui/ink-ui.ts:604~624

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant